Skip to content

Shared Layout

The Vercel dashboard has this rather fun nav animation:

As I hover over different nav links, a grey box slides behind them, following the cursor.

I've built this sort of effect from scratch before, and I can tell you that it's a ton of trouble. Fortunately, it's much easier with Framer Motion 😄. In this lesson, we'll see how to recreate this effect.

Let's do things a bit differently for this lesson. We'll start with a functional (but not quite complete) implementation. I'll walk you through how it works later, but first, I want you to do some experimentation on your own.

Here's the code. Spend 5ish minutes poking at it, seeing if you can figure out how it works.

Your mission is to see if you can make sense of the code, and figure out how that translates into the actual DOM. Use the “Elements” pane of the devtools to help you dig into what's going on.

Also: there's a subtle visual issue with this approach. Can you spot it?

Code Playground

import React from 'react';
import { motion } from 'framer-motion';

function Navigation() {
const [hoveredNavItem, setHoveredNavItem] =
React.useState(null);

return (
<nav
onMouseLeave={() => setHoveredNavItem(null)}
>
<ul>
{LINKS.map(({ slug, label, href }) => (
<li key={slug}>
{hoveredNavItem === slug && (
<motion.div
layoutId="hovered-backdrop"
className="hovered-backdrop"
/>
)}

<a
href={href}
onMouseEnter={() =>
setHoveredNavItem(slug)
}
>
{label}
</a>
</li>
))}
</ul>
</nav>
);
}

const LINKS = [
{
slug: 'home',
label: 'Home',
href: '/',
},
{
slug: 'usage',
label: 'Usage',
href: '/usage',
},
{
slug: 'integrations',
label: 'Integrations',
href: '/integrations',
},
];

export default Navigation;

Alright, so now that you've taken a few minutes to explore this implementation, let's talk about it.

Video Summary

This demo involves some trickery. Things aren't as they appear.

The illusion is that we have a single backdrop rectangle that slides around from item to item, following the cursor. But when we look at the code, we start to realize that things don't really add up.

We're iterating over a LINKS array, creating an <li> for each one. This means we have 3 list items, with 3 anchor tags, and 3 conditionally-rendered <motion.div> elements.

By applying a layoutId to the motion components, we tell Framer Motion that we want to create the illusion of a single element, when in reality there are several individual elements. We do this by giving each motion component the same layoutId value.

To really see what's going on, we can apply a different background color to each backdrop element:

In terms of the API, the layoutId prop accepts a string. In general, we don't want to hardcode this; otherwise, things may behave unpredictably if several component instances exist at one time.

Instead, we should use the React.useId() hook to generate a unique ID for each instance:

function Navigation() {
const [hoveredNavItem, setHoveredNavItem] = React.useState(null);
const id = React.useId();
return (
<nav onMouseLeave={() => setHoveredNavItem(null)}>
<ul>
{LINKS.map(({ slug, label, href }) => (
<li key={slug}>
{hoveredNavItem === slug && (
<motion.div
layoutId={id}
className="hovered-backdrop"
/>
)}
<a
href={href}
onMouseEnter={() => setHoveredNavItem(slug)}
>
{label}
</a>
</li>
))}
</ul>
</nav>
);
}

Above, I mentioned there was a subtle visual issue with this approach. The trouble is that the rounded corners get a little distorted during certain transitions.

This effect is more obvious if we crank up the border-radius:

This is another one of the unfortunate side-effects of using CSS scale transforms. “Integrations” is a longer word than “Usage”, and so the backdrop rectangle needs to grow/shrink as it moves between them. It winds up stretching or squashing the rectangle, which affects the perceived border radius.

Framer Motion does know how to solve this problem, but we need to explicitly tell it about the border radius. We can do it like this:

{hoveredNavItem === slug && (
<motion.div
layoutId={id}
className="hovered-backdrop"
animate={{
borderRadius: 32,
}}
/>
)}

This solves our problem, but it introduces a new one: we've created an “enter animation”, where the element animates from the default border radius to 32px:

We can fix this by disabling enter animations:

{hoveredNavItem === slug && (
<motion.div
layoutId={id}
className="hovered-backdrop"
initial={false}
animate={{
borderRadius: 32,
}}
/>
)}

There's a slightly-simpler way to do this as well; we can set initial values directly:

{hoveredNavItem === slug && (
<motion.div
layoutId={id}
className="hovered-backdrop"
initial={{
borderRadius: 32,
}}
/>
)}

This solves the problem, but how?!

It uses a familiar trick: it dynamically updates the border-radius property to be the inverse of what it appears to be. Essentially, it cancels out the stretched/squashed corner by doing the opposite.

Now, dynamically changing border-radius on every frame is a bit more expensive than changing transform, which is super optimized. I'm guessing this is why we need to explicitly opt-in to this behaviour; surely Framer Motion could read the border-radius value from the element directly, but then it might wind up doing expensive and unnecessary border-radius tweaks.

The last thing we cover in this video is a common “gotcha” with this sort of shared element transition. Sometimes, the layering is wrong, and our shared element winds up in front of something it should be behind.

Notice that when the cursor moves over "Usage", the golden box appears to cover the word "Home":

This happens because each of our three <li> siblings are at the same "stacking level". As a result, the layering depends on DOM order. The 1st <li> will be behind the 2nd, the 2nd will be behind the 3rd.

To solve this problem, we need to dynamically change the z-index property, so that the currently-hovered element takes a step back, and sits behind its siblings.

Here's how we do it:

<li
key={slug}
style={{
zIndex: hoveredNavItem === slug ? 1 : 2,
}}
>
{/* Stuff here is unchanged */}
</li>

Shared layout transitions are tricky, and it takes some time to really build an intuition for how they work, and how to use them effectively. But they really do open a lot of doors in terms of animations that would otherwise not be practical to implement.

This technique is known as “shared layout animations”, and you can learn more in the Framer Motion docs:

Also, if you're not sure why that z-index solution worked towards the end of the video, you might want to check out my blog post, “What the Heck, z-index??”.

Here's the final playground from the video:

Code Playground

import React from 'react';
import { motion } from 'framer-motion';

function Navigation() {
const [hoveredNavItem, setHoveredNavItem] =
React.useState(null);

const id = React.useId();

return (
<nav
onMouseLeave={() => setHoveredNavItem(null)}
>
<ul>
{LINKS.map(({ slug, label, bg, href }) => (
<li
key={slug}
style={{
zIndex:
hoveredNavItem === slug ? 1 : 2,
}}
>
{hoveredNavItem === slug && (
<motion.div
layoutId={id}
className="hovered-backdrop"
initial={{
borderRadius: 8,
}}
style={{
backgroundColor: bg,
}}
/>
)}

<a
href={href}
onMouseEnter={() =>
setHoveredNavItem(slug)
}
>
{label}
</a>
</li>
))}
</ul>
</nav>
);
}

const LINKS = [
{
slug: 'home',
label: 'Home',
href: '/',
bg: 'hsl(250deg 100% 45%)',
},
{
slug: 'usage',
label: 'Usage',
href: '/usage',
bg: 'hsl(50deg 100% 35%)',
},
{
slug: 'integrations',
label: 'Integrations',